feat: per-company plugin enablement - #255
Open
stubbi wants to merge 15 commits into
Open
Conversation
…ocked) Per-company plugin enablement (spec 2026-07-18 §4.2): plugins may declare an opt-in default and a locked flag for instance-managed governance plugins. Absent field keeps today's default-on behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dmin Seeded through the existing role default-grant path (grantsForHumanRole -> ensureHumanRoleDefaultGrants); startup backfill adds it to existing owner/admin memberships. Grantable to any principal via principal_permission_grants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manifest-default-aware (companyEnablement.default) evaluation over plugin_company_settings rows, typed 403 plugin_not_enabled_for_company, and a fail-open event-bus deliverability checker. Adapted from contrib/company-plugin-enablement with the manifest-default delta. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Company-scoped events skip subscriptions of plugins disabled for that company (manifest default aware); lookup failures fail open so events are never silently dropped. Adapted from contrib/company-plugin-enablement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the documented no-op ensurePluginAvailableForCompany; every company-scoped host operation now fails closed with the typed 403 when the plugin is disabled for that company. Fixture fix beyond the brief: the current Task 3 gate (pluginCompanyEnablementService) fails closed on an *unknown* plugin id via registry.getById, unlike the simpler prior-art gate the brief's contingency note describes (which only consulted plugin_company_settings). So swapping the "plugin-record-id" literal for a real randomUUID() in plugin-orchestration-apis.test.ts and plugin-access-authorization-host-services.test.ts wasn't sufficient on its own — each suite now also seeds a matching `plugins` row (manifestJson empty, so the enablement default stays "on") so the gate resolves the same way these suites already assumed. Also adds a focused test proving localFolders.configure's plugin_company_settings upsert is unreachable for a company-disabled plugin: the gate (ensurePluginAvailableForCompany) already runs first in the existing code, before any read or write of that row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /plugins/companies/:companyId/catalog (surface-gated via PR-1 company.plugins, infrastructure plugins excluded, manifest-aware enabled state) and PUT /plugins/:pluginId/companies/:companyId/enablement (plugins:manage required, 409 plugin_enablement_locked for locked plugins, settingsJson/lastError preserved on toggle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…T cross-company test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blement bridge/data, bridge/action, data/:key, actions/:key, the bridge SSE stream, and plugin-scoped API routes with companyResolution now 403 (plugin_not_enabled_for_company) for company-disabled plugins; instance-scoped bridge calls are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blement tools/execute resolves the owning plugin from the namespaced tool name and 403s before either dispatch path (gateway or dispatcher); ui-contributions?companyId filters slots of company-disabled plugins server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
usePluginSlots keys and forwards the (already-declared) companyId filter so disabled plugins' slots vanish per company; adds catalog/enablement api methods and the companyCatalog query key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/company/settings/plugins lists the per-company catalog with enable/disable toggles (plugins:manage holders), locked plugins render as non-interactive managed-by-instance rows, and 403s redirect to the settings root as navigation misses. Also wires the "Plugins" tab into CompanySettingsNav (the mobile tab bar parallel to CompanySettingsSidebar) so the surface is reachable and correctly highlighted on narrow viewports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…spec This branch's plugin routes were missing from the OpenAPI specification and caused openapi-routes test failures. Added the two new company-scoped plugin routes to the spec following neighboring plugin-route entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s/page contributions - server/src/routes/plugins.ts: PUT local-folders/:folderKey now calls enablement.ensurePluginEnabledForCompany() before upserting plugin_company_settings, closing a gap where a non-enabled (default-off or locked) plugin could get an enabled:true row written by any company member. Sibling GET local-folders (list) and GET .../status routes get the same gate since they leak stored per-company folder config for disabled plugins. - ui/src/plugins/launchers.tsx: usePluginLaunchers now threads filters.companyId into both the query key and pluginsApi.listUiContributions(), mirroring usePluginSlots. Previously the hook silently dropped the companyId a caller (e.g. PluginLauncherOutlet) already passed in, fetching unfiltered contributions. - ui/src/pages/PluginPage.tsx: same fix — threads resolvedCompanyId into the ui-contributions query key/call so a disabled plugin's page slot isn't fetched for that company. - ui/src/api/plugins.ts: wrap pluginId/companyId in encodeURIComponent() in listCompanyPluginCatalog/setCompanyPluginEnabled for consistency with listUiContributions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stubbi
force-pushed
the
feat/company-plugin-enablement
branch
from
July 18, 2026 18:45
a7ce503 to
1d977b3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
Fixes #258
No pre-existing public issue tracks this; describing in-PR per the feature-request template. This branch deliberately converges with the pre-existing
contrib/company-plugin-enablementbranch (see CONTRIBUTING.md → search-first); at implementation time we diffed against it and reused matching enforcement wiring and authz tests where they lined up. Refs the earlier branch as related prior art:contrib/company-plugin-enablement.Problem or motivation
plugin_company_settings.enabledexists in the schema but nothing reads or writes it — plugin enablement is instance-global in practice. There is no way for a company to disable a plugin it doesn't want, and no way for an instance admin to install a plugin that is mandatory for every company but still tracked per-company (e.g. so a later admin override or audit trail is meaningful).Proposed solution
Add an optional manifest field
companyEnablement: { default: "on" | "off", locked?: boolean }(absent ⇒"on", today's behavior, zero impact on existing plugins).locked: truerenders as a non-interactive "Managed by instance" entry — only an instance admin can override it per company. AddGET /plugins/companies/:companyId/catalogandPUT /plugins/:pluginId/companies/:companyId/enablement(409 on alockedplugin), gated by a newplugins:managepermission (implicit for companyowner/admin, grantable viaprincipal_permission_grants); catalog reads additionally require thecompany.pluginssurface (PR-1). Centralize the actual enablement check in one helper,isPluginEnabledForCompany(pluginId, companyId), and apply it at all six places a plugin currently acts for a company: host-services company resolution, bridge data/action calls, event-bus delivery, agent-tool dispatch, plugin-scoped API routes, andui-contributionsslot filtering. Ship a company "Plugins" settings page (surfacecompany.plugins) with catalog + toggle + mobile nav entry.Alternatives considered
Leaving enablement instance-only (status quo) was rejected — it cannot support governance-style plugins (billing) that must be mandatory yet still track state per company, and gives companies no self-service control over optional plugins. A per-route ad hoc check at each of the six call sites (instead of one shared helper) was rejected as a correctness risk — a missed call site would silently leave a "disabled" plugin acting for a company; the single helper plus per-gate enforcement tests close that hole deliberately.
Roadmap alignment
Checked
ROADMAP.md— generic core plugin-lifecycle primitive, upstreamable, not overlapping planned core work. Full design atdocs/superpowers/specs/2026-07-18-settings-visibility-and-plugin-enablement-design.md§4.What Changed
companyEnablement?: { default: "on" | "off"; locked?: boolean }, validated with.strict()on the sub-schema.plugins:manageinPERMISSION_KEYS; implicit forowner/admincompany memberships, grantable to other principals.GET /plugins/companies/:companyId/catalog— installed + ready + catalog-eligible plugins with{ enabled, locked, defaultEnabled, hasCompanySettingsPage }; sandbox-provider/credential-broker infra plugins stay excluded via existing categories.PUT /plugins/:pluginId/companies/:companyId/enablement—{ enabled }; 409 forlockedplugins; write-time lock guarantee verified (host-services upsert path cannot silently enable a disabled/locked plugin — seeplugin-host-services.tslocalFolders.configurecall site, confirmed unreachable for gated plugins).isPluginEnabledForCompany(pluginId, companyId)helper inplugin-company-enablement.ts, applied at all six enforcement points: host-services company resolution, bridge data/actions, event-bus delivery, agent-tool dispatch, plugin-scoped API routes (companyResolution), andui-contributionsslot filtering.company.pluginssurface): catalog list, toggle, capability summary, link into an enabled plugin'scompanySettingsPage; mobileCompanySettingsNaventry added (initially missed, caught in review).Verification
cd server && pnpm typecheck— 0 errors.plugins:managerequired for toggle, viewer denied,lockedplugin returns 409, catalog read requirescompany.pluginssurface exposure.localFolderspath) and the launchers/PluginPagecompany-filter gap found in the same pass — both fixed and re-verified.ui-contributions.feat/company-plugin-enablement, basefeat/settings-surface-policy; final whole-branch review closed "with fixes" and re-verified.Risks
feat/settings-surface-policy(PR-1) — this PR only makes sense merged after PR-1, and inherits PR-1'scapabilities/surface-gate plumbing. Diff againstmainwill include PR-1's changes until PR-1 merges; review the PR-1-vs-PR-2 diff range, not the full branch diff."on", so every existing installed plugin keeps today's instance-global behavior with no data migration.lockedplugin can never be silently enabled via a different code path) was the main risk surface here — closed by an explicit gate ordering fix plus a dedicated regression test rather than by inspection alone.forbidden()throw inplugin-company-enablement.ts(DRY candidate), a redundantgetByIdper gated call (efficiency, not correctness), and a missing toggle-error-path UI test — tracked for follow-up, not this PR's scope.Model Used
Claude (Anthropic), Sonnet 5 (model id
claude-sonnet-5) driving Claude Code's subagent-driven SDD workflow — spec-driven task briefs, extended multi-step tool use, and an independent code-review pass per task, plus a dedicated whole-branch final review before merge.Screenshots
CompanyPlugins catalog — locked plugin row
Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template#NNN/github.com/paperclipai/paperclipURLs)docs/...,fix/...) and contains no internal Paperclip ticket id or instance-derived details